diff --git a/posts.go b/posts.go
index 7732b19..b7d1a03 100644
--- a/posts.go
+++ b/posts.go
@@ -1,1786 +1,1782 @@
/*
* Copyright © 2018-2021 Musing Studio LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package writefreely
import (
"database/sql"
"encoding/json"
"fmt"
"html/template"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/gosimple/slug"
"github.com/guregu/null"
"github.com/guregu/null/zero"
"github.com/kylemcc/twitter-text-go/extract"
"github.com/microcosm-cc/bluemonday"
stripmd "github.com/writeas/go-strip-markdown/v2"
"github.com/writeas/impart"
"github.com/writeas/monday"
"github.com/writeas/web-core/activitystreams"
"github.com/writeas/web-core/bots"
"github.com/writeas/web-core/converter"
"github.com/writeas/web-core/i18n"
"github.com/writeas/web-core/log"
"github.com/writeas/web-core/tags"
"github.com/writefreely/writefreely/page"
"github.com/writefreely/writefreely/parse"
)
const (
// Post ID length bounds
minIDLen = 10
maxIDLen = 10
userPostIDLen = 10
postIDLen = 10
postMetaDateFormat = "2006-01-02T15:04:05Z"
)
type PostType string
const (
postArch PostType = "archive"
shortCodeMore = ""
shortCodePaid = ""
shortCodeNoSig = ""
shortCodeEmailSub = ""
)
type (
AnonymousPost struct {
ID string
Content string
HTMLContent template.HTML
Font string
Language string
Direction string
Title string
GenTitle string
Description string
Author string
Views int64
Images []string
IsPlainText bool
IsCode bool
IsLinkable bool
}
AuthenticatedPost struct {
ID string `json:"id" schema:"id"`
Web bool `json:"web" schema:"web"`
*SubmittedPost
}
// SubmittedPost represents a post supplied by a client for publishing or
// updating. Since Title and Content can be updated to "", they are
// pointers that can be easily tested to detect changes.
SubmittedPost struct {
Slug *string `json:"slug" schema:"slug"`
Title *string `json:"title" schema:"title"`
Content *string `json:"body" schema:"body"`
Font string `json:"font" schema:"font"`
IsRTL converter.NullJSONBool `json:"rtl" schema:"rtl"`
Language converter.NullJSONString `json:"lang" schema:"lang"`
Created *string `json:"created" schema:"created"`
}
// Post represents a post as found in the database.
Post struct {
ID string `db:"id" json:"id"`
Slug null.String `db:"slug" json:"slug,omitempty"`
Font string `db:"text_appearance" json:"appearance"`
Language zero.String `db:"language" json:"language"`
RTL zero.Bool `db:"rtl" json:"rtl"`
Privacy int64 `db:"privacy" json:"-"`
OwnerID null.Int `db:"owner_id" json:"-"`
CollectionID null.Int `db:"collection_id" json:"-"`
PinnedPosition null.Int `db:"pinned_position" json:"-"`
Created time.Time `db:"created" json:"created"`
Updated time.Time `db:"updated" json:"updated"`
ViewCount int64 `db:"view_count" json:"-"`
LikeCount int64 `db:"like_count" json:"likes"`
Title zero.String `db:"title" json:"title"`
HTMLTitle template.HTML `db:"title" json:"-"`
Content string `db:"content" json:"body"`
HTMLContent template.HTML `db:"content" json:"-"`
HTMLExcerpt template.HTML `db:"content" json:"-"`
Tags []string `json:"tags"`
Images []string `json:"images,omitempty"`
IsPaid bool `json:"paid"`
OwnerName string `json:"owner,omitempty"`
}
// PublicPost holds properties for a publicly returned post, i.e. a post in
// a context where the viewer may not be the owner. As such, sensitive
// metadata for the post is hidden and properties supporting the display of
// the post are added.
PublicPost struct {
*Post
IsSubdomain bool `json:"-"`
IsTopLevel bool `json:"-"`
DisplayDate string `json:"-"`
Views int64 `json:"views"`
Likes int64 `json:"likes"`
Owner *PublicUser `json:"-"`
IsOwner bool `json:"-"`
URL string `json:"url,omitempty"`
Collection *CollectionObj `json:"collection,omitempty"`
}
CollectionPostPage struct {
*PublicPost
page.StaticPage
IsOwner bool
IsPinned bool
IsCustomDomain bool
Monetization string
Verification string
FediverseAuthor string
PinnedPosts *[]PublicPost
IsFound bool
IsAdmin bool
CanInvite bool
Silenced bool
// Helper field for Chorus mode
CollAlias string
}
RawPost struct {
Id, Slug string
Title string
Content string
Views int64
Font string
Created time.Time
Updated time.Time
IsRTL sql.NullBool
Language sql.NullString
OwnerID int64
CollectionID sql.NullInt64
Found bool
Gone bool
}
AnonymousAuthPost struct {
ID string `json:"id"`
Token string `json:"token"`
}
ClaimPostRequest struct {
*AnonymousAuthPost
CollectionAlias string `json:"collection"`
CreateCollection bool `json:"create_collection"`
// Generated properties
Slug string `json:"-"`
}
ClaimPostResult struct {
ID string `json:"id,omitempty"`
Code int `json:"code,omitempty"`
ErrorMessage string `json:"error_msg,omitempty"`
Post *PublicPost `json:"post,omitempty"`
}
)
func (p *Post) Direction() string {
if p.RTL.Valid {
if p.RTL.Bool {
return "rtl"
}
return "ltr"
}
return "auto"
}
// DisplayTitle dynamically generates a title from the Post's contents if it
// doesn't already have an explicit title.
func (p *Post) DisplayTitle() string {
if p.Title.String != "" {
return p.Title.String
}
t := friendlyPostTitle(p.Content, p.ID)
return t
}
// PlainDisplayTitle strips away Markdown and HTML from the generated Post's
// title (if any), for use in places like RSS feeds and ActivityStreams objects,
// where only plain text is wanted.
func (p *Post) PlainDisplayTitle() string {
if t := stripmd.Strip(stripHTMLWithoutEscaping(p.DisplayTitle())); t != "" {
return t
}
return p.ID
}
// FormattedDisplayTitle dynamically generates a title from the Post's contents if it
// doesn't already have an explicit title.
func (p *Post) FormattedDisplayTitle() template.HTML {
if p.HTMLTitle != "" {
return p.HTMLTitle
}
return template.HTML(p.DisplayTitle())
}
// Summary gives a shortened summary of the post based on the post's title,
// especially for display in a longer list of posts. It extracts a summary for
// posts in the Title\n\nBody format, returning nothing if the entire was short
// enough that the extracted title == extracted summary.
func (p Post) Summary() string {
if p.Content == "" {
return ""
}
p.Content = stripHTMLWithoutEscaping(p.Content)
// and Markdown
p.Content = stripmd.StripOptions(p.Content, stripmd.Options{SkipImages: true})
title := p.Title.String
var desc string
if title == "" {
// No title, so generate one
title = friendlyPostTitle(p.Content, p.ID)
desc = postDescription(p.Content, title, p.ID)
if desc == title {
return ""
}
return desc
}
return shortPostDescription(p.Content)
}
func (p Post) SummaryHTML() template.HTML {
return template.HTML(p.Summary())
}
// Excerpt shows any text that comes before a (more) tag.
// TODO: use HTMLExcerpt in templates instead of this method
func (p *Post) Excerpt() template.HTML {
return p.HTMLExcerpt
}
func (p *Post) CreatedDate() string {
return p.Created.Format("2006-01-02")
}
func (p *Post) Created8601() string {
return p.Created.Format("2006-01-02T15:04:05Z")
}
func (p *Post) IsScheduled() bool {
return p.Created.After(time.Now())
}
func (p *Post) HasTag(tag string) bool {
// Regexp looks for tag and has a non-capturing group at the end looking
// for the end of the word.
// Assisted by: https://stackoverflow.com/a/35192941/1549194
hasTag, _ := regexp.MatchString("#"+tag+`(?:[[:punct:]]|\s|\z)`, p.Content)
return hasTag
}
func (p *Post) HasTitleLink() bool {
if p.Title.String == "" {
return false
}
hasLink, _ := regexp.MatchString(`([^!]+|^)\[.+\]\(.+\)`, p.Title.String)
return hasLink
}
// UserPage provides the fields expected by the shared "user-navigation"
// template, which otherwise assumes it's rendering for a page that embeds
// *UserPage (e.g. the "me" backend pages).
func (c CollectionPostPage) UserPage() *UserPage {
return &UserPage{
StaticPage: c.StaticPage,
IsAdmin: c.IsAdmin,
CanInvite: c.CanInvite,
CollAlias: c.CollAlias,
}
}
func (c CollectionPostPage) DisplayMonetization() string {
if c.Collection == nil {
log.Info("CollectionPostPage.DisplayMonetization: c.Collection is nil")
return ""
}
return displayMonetization(c.Monetization, c.Collection.Alias)
}
func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
friendlyID := vars["post"]
// NOTE: until this is done better, be sure to keep this in parity with
// isRaw() and viewCollectionPost()
isJSON := strings.HasSuffix(friendlyID, ".json")
isXML := strings.HasSuffix(friendlyID, ".xml")
isCSS := strings.HasSuffix(friendlyID, ".css")
isMarkdown := strings.HasSuffix(friendlyID, ".md")
isRaw := strings.HasSuffix(friendlyID, ".txt") || isJSON || isXML || isCSS || isMarkdown
// Display reserved page if that is requested resource
if t, ok := pages[r.URL.Path[1:]+".tmpl"]; ok {
return handleTemplatedPage(app, w, r, t)
} else if r.URL.Path == "/sitemap.xml" && !app.cfg.App.SingleUser {
return impart.HTTPError{Status: http.StatusNotFound, Message: "Page not found."}
} else if (strings.Contains(r.URL.Path, ".") && !isRaw && !isMarkdown) || r.URL.Path == "/robots.txt" || r.URL.Path == "/manifest.json" {
// Serve static file
app.shttp.ServeHTTP(w, r)
return nil
}
// Display collection if this is a collection
c, _ := app.db.GetCollection(friendlyID)
if c != nil {
return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s/", friendlyID)}
}
// Normalize the URL, redirecting user to consistent post URL
if friendlyID != strings.ToLower(friendlyID) {
return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s", strings.ToLower(friendlyID))}
}
ext := ""
if isRaw {
parts := strings.Split(friendlyID, ".")
friendlyID = parts[0]
if len(parts) > 1 {
ext = "." + parts[1]
}
}
var ownerID sql.NullInt64
var collectionID sql.NullInt64
var title string
var content string
var font string
var language []byte
var rtl []byte
var views int64
var post *AnonymousPost
var found bool
var gone bool
fixedID := slug.Make(friendlyID)
if fixedID != friendlyID {
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/%s%s", fixedID, ext)}
}
err := app.db.QueryRow("SELECT owner_id, collection_id, title, content, text_appearance, view_count, language, rtl FROM posts WHERE id = ?", friendlyID).Scan(&ownerID, &collectionID, &title, &content, &font, &views, &language, &rtl)
switch {
case err == sql.ErrNoRows:
found = false
// Output the error in the correct format
if isJSON {
content = "{\"error\": \"Post not found.\"}"
} else if isRaw {
content = "Post not found."
} else {
return ErrPostNotFound
}
case err != nil:
found = false
log.Error("Post loading err: %s\n", err)
return ErrInternalGeneral
default:
found = true
var d string
if len(rtl) == 0 {
d = "auto"
} else if rtl[0] == 49 {
// TODO: find a cleaner way to get this (possibly NULL) value
d = "rtl"
} else {
d = "ltr"
}
generatedTitle := friendlyPostTitle(content, friendlyID)
sanitizedContent := content
if font != "code" {
sanitizedContent = template.HTMLEscapeString(content)
}
var desc string
if title == "" {
desc = postDescription(content, title, friendlyID)
} else {
desc = shortPostDescription(content)
}
post = &AnonymousPost{
ID: friendlyID,
Content: sanitizedContent,
Title: title,
GenTitle: generatedTitle,
Description: desc,
Author: "",
Font: font,
IsPlainText: isRaw,
IsCode: font == "code",
IsLinkable: font != "code",
Views: views,
Language: string(language),
Direction: d,
}
if !isRaw {
post.HTMLContent = template.HTML(applyMarkdown([]byte(content), "", app.cfg))
post.Images = extractImages(post.Content)
}
}
var silenced bool
if found {
silenced, err = app.db.IsUserSilenced(ownerID.Int64)
if err != nil {
log.Error("view post: %v", err)
}
}
var protectDraft bool
if found && collectionID.Valid {
collection, err := app.db.GetCollectionByID(collectionID.Int64)
if err != nil {
log.Error("view post: %v", err)
}
protectDraft = collection.IsPrivate() || collection.IsProtected()
}
// Check if post has been unpublished
if title == "" && content == "" {
gone = true
if isJSON {
content = "{\"error\": \"Post was unpublished.\"}"
} else if isCSS {
content = ""
} else if isRaw {
content = "Post was unpublished."
} else {
return ErrPostUnpublished
}
}
var u = &User{}
if isRaw {
contentType := "text/plain"
if isJSON {
contentType = "application/json"
} else if isCSS {
contentType = "text/css"
} else if isXML {
contentType = "application/xml"
} else if isMarkdown {
contentType = "text/markdown"
}
w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType))
if isMarkdown && post.Title != "" {
fmt.Fprintf(w, "%s\n", post.Title)
for i := 1; i <= len(post.Title); i++ {
fmt.Fprintf(w, "=")
}
fmt.Fprintf(w, "\n\n")
}
fmt.Fprint(w, content)
if !found {
return ErrPostNotFound
} else if gone {
return ErrPostUnpublished
}
} else {
var err error
page := struct {
*AnonymousPost
page.StaticPage
Username string
IsOwner bool
SiteURL string
Silenced bool
}{
AnonymousPost: post,
StaticPage: pageForReq(app, r),
SiteURL: app.cfg.App.Host,
}
if u = getUserSession(app, r); u != nil {
page.Username = u.Username
page.IsOwner = ownerID.Valid && ownerID.Int64 == u.ID
}
if !page.IsOwner && silenced {
return ErrPostNotFound
}
if !page.IsOwner && protectDraft {
return ErrPostNotFound
}
page.Silenced = silenced
err = templates["post"].ExecuteTemplate(w, "post", page)
if err != nil {
log.Error("Post template execute error: %v", err)
}
}
go func() {
if u != nil && ownerID.Valid && ownerID.Int64 == u.ID {
// Post is owned by someone; skip view increment since that person is viewing this post.
return
}
// Update stats for non-raw post views
if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) {
_, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE id = ?", friendlyID)
if err != nil {
log.Error("Unable to update posts count: %v", err)
}
}
}()
return nil
}
// API v2 funcs
// newPost creates a new post with or without an owning Collection.
//
// Endpoints:
// - /posts
// - /posts?collection={alias}
// - ? /collections/{alias}/posts
func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r)
vars := mux.Vars(r)
collAlias := vars["alias"]
if collAlias == "" {
collAlias = r.FormValue("collection")
}
accessToken := r.Header.Get("Authorization")
if accessToken == "" {
// TODO: remove this
accessToken = r.FormValue("access_token")
}
// FIXME: determine web submission with Content-Type header
var u *User
var userID int64 = -1
var username string
if accessToken == "" {
u = getUserSession(app, r)
if u != nil {
userID = u.ID
username = u.Username
}
} else {
userID = app.db.GetUserID(accessToken)
}
silenced, err := app.db.IsUserSilenced(userID)
if err != nil {
log.Error("new post: %v", err)
}
if silenced {
return ErrUserSilenced
}
if userID == -1 {
return ErrNotLoggedIn
}
if accessToken == "" && u == nil && collAlias != "" {
return impart.HTTPError{http.StatusBadRequest, "Parameter `access_token` required."}
}
// Get post data
var p *SubmittedPost
if reqJSON {
decoder := json.NewDecoder(r.Body)
err = decoder.Decode(&p)
if err != nil {
log.Error("Couldn't parse new post JSON request: %v\n", err)
return ErrBadJSON
}
if p.Title == nil {
t := ""
p.Title = &t
}
if strings.TrimSpace(*(p.Title)) == "" && (p.Content == nil || strings.TrimSpace(*(p.Content)) == "") {
return ErrNoPublishableContent
}
if p.Content == nil {
c := ""
p.Content = &c
}
} else {
post := r.FormValue("body")
appearance := r.FormValue("font")
title := r.FormValue("title")
rtlValue := r.FormValue("rtl")
langValue := r.FormValue("lang")
if strings.TrimSpace(post) == "" {
return ErrNoPublishableContent
}
var isRTL, rtlValid bool
if rtlValue == "auto" && langValue != "" {
isRTL = i18n.LangIsRTL(langValue)
rtlValid = true
} else {
isRTL = rtlValue == "true"
rtlValid = rtlValue != "" && langValue != ""
}
// Create a new post
p = &SubmittedPost{
Title: &title,
Content: &post,
Font: appearance,
IsRTL: converter.NullJSONBool{sql.NullBool{Bool: isRTL, Valid: rtlValid}},
Language: converter.NullJSONString{sql.NullString{String: langValue, Valid: langValue != ""}},
}
}
if !p.isFontValid() {
p.Font = "norm"
}
var newPost *PublicPost = &PublicPost{}
var coll *Collection
if accessToken != "" {
newPost, err = app.db.CreateOwnedPost(p, accessToken, collAlias, app.cfg.App.Host)
} else {
//return ErrNotLoggedIn
// TODO: verify user is logged in
var collID int64
if collAlias != "" {
coll, err = app.db.GetCollection(collAlias)
if err != nil {
return err
}
coll.hostName = app.cfg.App.Host
if coll.OwnerID != u.ID {
return ErrForbiddenCollection
}
collID = coll.ID
}
// TODO: return PublicPost from createPost
newPost.Post, err = app.db.CreatePost(userID, collID, p)
}
if err != nil {
return err
}
if coll != nil {
coll.ForPublic()
newPost.Collection = &CollectionObj{Collection: *coll}
}
newPost.extractData()
newPost.OwnerName = username
newPost.URL = newPost.CanonicalURL(app.cfg.App.Host)
// Write success now
response := impart.WriteSuccess(w, newPost, http.StatusCreated)
if newPost.Collection != nil {
if !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) {
go federatePost(app, newPost, newPost.Collection.ID, false)
}
if app.cfg.Email.Enabled() && newPost.Collection.EmailSubsEnabled() {
go app.db.InsertJob(&PostJob{
PostID: newPost.ID,
Action: "email",
Delay: emailSendDelay,
})
}
}
return response
}
func existingPost(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r)
vars := mux.Vars(r)
postID := vars["post"]
p := AuthenticatedPost{ID: postID}
var err error
if reqJSON {
// Decode JSON request
decoder := json.NewDecoder(r.Body)
err = decoder.Decode(&p)
if err != nil {
log.Error("Couldn't parse post update JSON request: %v\n", err)
return ErrBadJSON
}
} else {
err = r.ParseForm()
if err != nil {
log.Error("Couldn't parse post update form request: %v\n", err)
return ErrBadFormData
}
// Can't decode to a nil SubmittedPost property, so create instance now
p.SubmittedPost = &SubmittedPost{}
err = app.formDecoder.Decode(&p, r.PostForm)
if err != nil {
log.Error("Couldn't decode post update form request: %v\n", err)
return ErrBadFormData
}
}
if p.Web {
p.IsRTL.Valid = true
}
if p.SubmittedPost == nil {
return ErrPostNoUpdatableVals
}
// Ensure an access token was given
accessToken := r.Header.Get("Authorization")
// Get user's cookie session if there's no token
var u *User
//var username string
if accessToken == "" {
u = getUserSession(app, r)
if u != nil {
//username = u.Username
}
}
if u == nil && accessToken == "" {
return ErrNoAccessToken
}
// Get user ID from current session or given access token, if one was given.
var userID int64
if u != nil {
userID = u.ID
} else if accessToken != "" {
userID, err = AuthenticateUser(app.db, accessToken)
if err != nil {
return err
}
}
silenced, err := app.db.IsUserSilenced(userID)
if err != nil {
log.Error("existing post: %v", err)
}
if silenced {
return ErrUserSilenced
}
// Modify post struct
p.ID = postID
err = app.db.UpdateOwnedPost(&p, userID)
if err != nil {
if reqJSON {
return err
}
if err, ok := err.(impart.HTTPError); ok {
addSessionFlash(app, w, r, err.Message, nil)
} else {
addSessionFlash(app, w, r, err.Error(), nil)
}
}
var pRes *PublicPost
pRes, err = app.db.GetPost(p.ID, 0)
if reqJSON {
if err != nil {
return err
}
pRes.extractData()
}
if pRes.CollectionID.Valid {
coll, err := app.db.GetCollectionBy("id = ?", pRes.CollectionID.Int64)
if err == nil && !app.cfg.App.Private && app.cfg.App.Federation {
coll.hostName = app.cfg.App.Host
pRes.Collection = &CollectionObj{Collection: *coll}
go federatePost(app, pRes, pRes.Collection.ID, true)
}
}
// Write success now
if reqJSON {
return impart.WriteSuccess(w, pRes, http.StatusOK)
}
addSessionFlash(app, w, r, "Changes saved.", nil)
collectionAlias := vars["alias"]
- redirect := "/" + postID + "/meta"
+ redirect := "/d/" + postID + "/edit/meta"
if collectionAlias != "" {
collPre := "/" + collectionAlias
if app.cfg.App.SingleUser {
collPre = ""
}
redirect = collPre + "/" + pRes.Slug.String + "/edit/meta"
- } else {
- if app.cfg.App.SingleUser {
- redirect = "/d" + redirect
- }
}
w.Header().Set("Location", redirect)
w.WriteHeader(http.StatusFound)
return nil
}
func deletePost(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
friendlyID := vars["post"]
editToken := r.FormValue("token")
var ownerID int64
var u *User
accessToken := r.Header.Get("Authorization")
if accessToken == "" && editToken == "" {
u = getUserSession(app, r)
if u == nil {
return ErrNoAccessToken
}
}
var res sql.Result
var t *sql.Tx
var err error
var collID sql.NullInt64
var coll *Collection
var pp *PublicPost
if editToken != "" {
// TODO: SELECT owner_id, as well, and return appropriate error if NULL instead of running two queries
var dummy int64
err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ?", friendlyID).Scan(&dummy)
switch {
case err == sql.ErrNoRows:
return impart.HTTPError{http.StatusNotFound, "Post not found."}
}
err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ? AND owner_id IS NULL", friendlyID).Scan(&dummy)
switch {
case err == sql.ErrNoRows:
// Post already has an owner. This could provide a bad experience
// for the user, but it's more important to ensure data isn't lost
// unexpectedly. So prevent deletion via token.
return impart.HTTPError{http.StatusConflict, "This post belongs to some user (hopefully yours). Please log in and delete it from that user's account."}
}
res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND modify_token = ? AND owner_id IS NULL", friendlyID, editToken)
} else if accessToken != "" || u != nil {
// Caller provided some way to authenticate; assume caller expects the
// post to be deleted based on a specific post owner, thus we should
// return corresponding errors.
if accessToken != "" {
ownerID = app.db.GetUserID(accessToken)
if ownerID == -1 {
return ErrBadAccessToken
}
} else {
ownerID = u.ID
}
// TODO: don't make two queries
var realOwnerID sql.NullInt64
err = app.db.QueryRow("SELECT collection_id, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&collID, &realOwnerID)
if err != nil {
return err
}
if !collID.Valid {
// There's no collection; simply delete the post
res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID)
} else {
// Post belongs to a collection; do any additional clean up
coll, err = app.db.GetCollectionBy("id = ?", collID.Int64)
if err != nil {
log.Error("Unable to get collection: %v", err)
return err
}
if app.cfg.App.Federation {
// First fetch full post for federation
pp, err = app.db.GetOwnedPost(friendlyID, ownerID)
if err != nil {
log.Error("Unable to get owned post: %v", err)
return err
}
collObj := &CollectionObj{Collection: *coll}
pp.Collection = collObj
}
t, err = app.db.Begin()
if err != nil {
log.Error("No begin: %v", err)
return err
}
res, err = t.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID)
}
} else {
return impart.HTTPError{http.StatusBadRequest, "No authenticated user or post token given."}
}
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
if t != nil {
t.Rollback()
log.Error("Rows affected err! Rolling back")
}
return err
} else if affected == 0 {
if t != nil {
t.Rollback()
log.Error("No rows affected! Rolling back")
}
return impart.HTTPError{http.StatusForbidden, "Post not found, or you're not the owner."}
}
if t != nil {
t.Commit()
}
if coll != nil && !app.cfg.App.Private && app.cfg.App.Federation {
go deleteFederatedPost(app, pp, collID.Int64)
}
return impart.HTTPError{Status: http.StatusNoContent}
}
// addPost associates a post with the authenticated user.
func addPost(app *App, w http.ResponseWriter, r *http.Request) error {
var ownerID int64
// Authenticate user
at := r.Header.Get("Authorization")
if at != "" {
ownerID = app.db.GetUserID(at)
if ownerID == -1 {
return ErrBadAccessToken
}
} else {
u := getUserSession(app, r)
if u == nil {
return ErrNotLoggedIn
}
ownerID = u.ID
}
silenced, err := app.db.IsUserSilenced(ownerID)
if err != nil {
log.Error("add post: %v", err)
}
if silenced {
return ErrUserSilenced
}
// Parse claimed posts in format:
// [{"id": "...", "token": "..."}]
var claims *[]ClaimPostRequest
decoder := json.NewDecoder(r.Body)
err = decoder.Decode(&claims)
if err != nil {
return ErrBadJSONArray
}
vars := mux.Vars(r)
collAlias := vars["alias"]
// Update all given posts
res, err := app.db.ClaimPosts(app.cfg, ownerID, collAlias, claims)
if err != nil {
return err
}
for _, pRes := range *res {
if pRes.Code != http.StatusOK {
continue
}
if !app.cfg.App.Private && app.cfg.App.Federation {
if !pRes.Post.Created.After(time.Now()) {
pRes.Post.Collection.hostName = app.cfg.App.Host
go federatePost(app, pRes.Post, pRes.Post.Collection.ID, false)
}
}
if app.cfg.Email.Enabled() && pRes.Post.Collection.EmailSubsEnabled() {
go app.db.InsertJob(&PostJob{
PostID: pRes.Post.ID,
Action: "email",
Delay: emailSendDelay,
})
}
}
return impart.WriteSuccess(w, res, http.StatusOK)
}
func dispersePost(app *App, w http.ResponseWriter, r *http.Request) error {
var ownerID int64
// Authenticate user
at := r.Header.Get("Authorization")
if at != "" {
ownerID = app.db.GetUserID(at)
if ownerID == -1 {
return ErrBadAccessToken
}
} else {
u := getUserSession(app, r)
if u == nil {
return ErrNotLoggedIn
}
ownerID = u.ID
}
// Parse posts in format:
// ["..."]
var postIDs []string
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&postIDs)
if err != nil {
return ErrBadJSONArray
}
// Update all given posts
res, err := app.db.DispersePosts(ownerID, postIDs)
if err != nil {
return err
}
return impart.WriteSuccess(w, res, http.StatusOK)
}
type (
PinPostResult struct {
ID string `json:"id,omitempty"`
Code int `json:"code,omitempty"`
ErrorMessage string `json:"error_msg,omitempty"`
}
)
// pinPost pins a post to a blog
func pinPost(app *App, w http.ResponseWriter, r *http.Request) error {
var userID int64
// Authenticate user
at := r.Header.Get("Authorization")
if at != "" {
userID = app.db.GetUserID(at)
if userID == -1 {
return ErrBadAccessToken
}
} else {
u := getUserSession(app, r)
if u == nil {
return ErrNotLoggedIn
}
userID = u.ID
}
silenced, err := app.db.IsUserSilenced(userID)
if err != nil {
log.Error("pin post: %v", err)
}
if silenced {
return ErrUserSilenced
}
// Parse request
var posts []struct {
ID string `json:"id"`
Position int64 `json:"position"`
}
decoder := json.NewDecoder(r.Body)
err = decoder.Decode(&posts)
if err != nil {
return ErrBadJSONArray
}
// Validate data
vars := mux.Vars(r)
collAlias := vars["alias"]
coll, err := app.db.GetCollection(collAlias)
if err != nil {
return err
}
if coll.OwnerID != userID {
return ErrForbiddenCollection
}
// Do (un)pinning
isPinning := r.URL.Path[strings.LastIndex(r.URL.Path, "/"):] == "/pin"
res := []PinPostResult{}
for _, p := range posts {
err = app.db.UpdatePostPinState(isPinning, p.ID, coll.ID, userID, p.Position)
ppr := PinPostResult{ID: p.ID}
if err != nil {
if err == ErrForbiddenCollection {
ppr.Code = http.StatusForbidden
} else {
ppr.Code = http.StatusInternalServerError
}
// TODO: set error message
} else {
ppr.Code = http.StatusOK
}
res = append(res, ppr)
}
return impart.WriteSuccess(w, res, http.StatusOK)
}
func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
var collID int64
var coll *Collection
var err error
vars := mux.Vars(r)
if collAlias := vars["alias"]; collAlias != "" {
// Fetch collection information, since an alias is provided
coll, err = app.db.GetCollection(collAlias)
if err != nil {
return err
}
collID = coll.ID
}
p, err := app.db.GetPost(vars["post"], collID)
if err != nil {
return err
}
if coll == nil && p.CollectionID.Valid {
// Collection post is getting fetched by post ID, not coll alias + post slug, so get coll info now.
coll, err = app.db.GetCollectionByID(p.CollectionID.Int64)
if err != nil {
return err
}
}
if coll != nil {
coll.hostName = app.cfg.App.Host
_, err = apiCheckCollectionPermissions(app, r, coll)
if err != nil {
return err
}
}
silenced, err := app.db.IsUserSilenced(p.OwnerID.Int64)
if err != nil {
log.Error("fetch post: %v", err)
}
if silenced {
return ErrPostNotFound
}
p.extractData()
if IsActivityPubRequest(r) {
if coll == nil {
// This is a draft post; 404 for now
// TODO: return ActivityObject
return impart.HTTPError{http.StatusNotFound, ""}
}
p.Collection = &CollectionObj{Collection: *coll}
po := p.ActivityObject(app)
po.Context = []interface{}{activitystreams.Namespace}
setCacheControl(w, apCacheTime)
return impart.RenderActivityJSON(w, po, http.StatusOK)
}
return impart.WriteSuccess(w, p, http.StatusOK)
}
func fetchPostProperty(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
p, err := app.db.GetPostProperty(vars["post"], 0, vars["property"])
if err != nil {
return err
}
return impart.WriteSuccess(w, p, http.StatusOK)
}
func (p *Post) processPost() PublicPost {
res := &PublicPost{Post: p, Views: 0}
res.Views = p.ViewCount
res.Likes = p.LikeCount
// TODO: move to own function
loc := monday.FuzzyLocale(p.Language.String)
res.DisplayDate = monday.Format(p.Created, monday.LongFormatsByLocale[loc], loc)
return *res
}
func (p *PublicPost) CanonicalURL(hostName string) string {
if p.Collection == nil || p.Collection.Alias == "" {
return hostName + "/" + p.ID + ".md"
}
return p.Collection.CanonicalURL() + p.Slug.String
}
func (pp *PublicPost) DisplayCanonicalURL() string {
us := pp.CanonicalURL(pp.Collection.hostName)
u, err := url.Parse(us)
if err != nil {
return us
}
return u.Hostname() + u.Path
}
func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object {
cfg := app.cfg
var o *activitystreams.Object
if cfg.App.NotesOnly || strings.Index(p.Content, "\n\n") == -1 {
o = activitystreams.NewNoteObject()
} else {
o = activitystreams.NewArticleObject()
}
o.ID = p.Collection.FederatedAPIBase() + "api/posts/" + p.ID
o.Published = p.Created
o.URL = p.CanonicalURL(cfg.App.Host)
o.AttributedTo = p.Collection.FederatedAccount()
o.CC = []string{
p.Collection.FederatedAccount() + "/followers",
}
o.Name = p.PlainDisplayTitle()
p.augmentContent()
if p.HTMLContent == template.HTML("") {
p.formatContent(cfg, false, false)
p.augmentReadingDestination()
}
o.Content = string(p.HTMLContent)
if o.Type == "Note" && p.Title.String != "" {
// Render the explicitly-set title inside the Note, since Mastodon (at least) doesn't show the `name`
// property on Notes.
o.Content = "
" + applyBasicMarkdown([]byte(p.DisplayTitle())) + " \n\n" + o.Content
}
if p.Language.Valid {
o.ContentMap = map[string]string{
p.Language.String: string(p.HTMLContent),
}
}
if len(p.Tags) == 0 {
o.Tag = []activitystreams.Tag{}
} else {
var tagBaseURL string
if isSingleUser {
tagBaseURL = p.Collection.CanonicalURL() + "tag:"
} else {
if cfg.App.Chorus {
tagBaseURL = fmt.Sprintf("%s/read/t/", p.Collection.hostName)
} else {
tagBaseURL = fmt.Sprintf("%s/%s/tag:", p.Collection.hostName, p.Collection.Alias)
}
}
for _, t := range p.Tags {
o.Tag = append(o.Tag, activitystreams.Tag{
Type: activitystreams.TagHashtag,
HRef: tagBaseURL + t,
Name: "#" + t,
})
}
}
if len(p.Images) > 0 {
for _, i := range p.Images {
o.Attachment = append(o.Attachment, activitystreams.NewImageAttachment(i))
}
}
// Find mentioned users
mentionedUsers := make(map[string]string)
stripper := bluemonday.StrictPolicy()
content := stripper.Sanitize(p.Content)
mentions := mentionReg.FindAllString(content, -1)
for _, handle := range mentions {
actorIRI, err := app.db.GetProfilePageFromHandle(app, handle)
if err != nil || actorIRI == "" {
log.Info("Couldn't find user '%s' locally or remotely", handle)
continue
}
mentionedUsers[handle] = actorIRI
}
for handle, iri := range mentionedUsers {
o.CC = append(o.CC, iri)
o.Tag = append(o.Tag, activitystreams.Tag{Type: "Mention", HRef: iri, Name: handle})
}
// Add shortened Note as the `preview` property if this is an Article
if o.Type == "Article" {
o.Preview = p.PreviewObject(app, o)
o.Summary = &o.Preview.Content
}
return o
}
// PreviewObject returns an activitystreams.Object that can be used as an Article's `preview` property.
func (p *PublicPost) PreviewObject(app *App, art *activitystreams.Object) *activitystreams.Object {
o := activitystreams.NewNoteObject()
o.To = nil
o.ID = art.ID
o.URL = art.URL
o.Published = art.Published
o.Updated = art.Updated
o.Tag = art.Tag
o.Attachment = art.Attachment
baseURL := p.Collection.CanonicalURL()
// Try to truncate at user-defined excerpt, if exists
exc := strings.Index(p.Content, shortCodeMore)
if exc == -1 {
// No excerpt; fall back to truncating at first paragraph
exc = strings.Index(p.Content, "\n\n")
}
if exc > -1 {
p.HTMLExcerpt = template.HTML(applyMarkdown([]byte(p.Content[:exc]+" [...]"), baseURL, app.cfg))
} else {
p.HTMLExcerpt = p.HTMLContent
}
o.Content = strings.TrimRight(string(p.Excerpt()), "\n")
return o
}
// TODO: merge this into getSlugFromPost or phase it out
func getSlug(title, lang string) string {
return getSlugFromPost("", title, lang)
}
func getSlugFromPost(title, body, lang string) string {
if title == "" {
// Remove Markdown, so e.g. link URLs and image alt text don't make it into the slug
body = strings.TrimSpace(stripmd.StripOptions(body, stripmd.Options{SkipImages: true}))
title = postTitle(body, body)
}
title = parse.PostLede(title, false)
// Truncate lede if needed
title, _ = parse.TruncToWord(title, 80)
var s string
if lang != "" && len(lang) == 2 {
s = slug.MakeLang(title, lang)
} else {
s = slug.Make(title)
}
// Transliteration may cause the slug to expand past the limit, so truncate again
s, _ = parse.TruncToWord(s, 80)
return strings.TrimFunc(s, func(r rune) bool {
// TruncToWord doesn't respect words in a slug, since spaces are replaced
// with hyphens. So remove any trailing hyphens.
return r == '-'
})
}
// isFontValid returns whether or not the submitted post's appearance is valid.
func (p *SubmittedPost) isFontValid() bool {
validFonts := map[string]bool{
"norm": true,
"sans": true,
"mono": true,
"wrap": true,
"code": true,
}
_, valid := validFonts[p.Font]
return valid
}
func getRawPost(app *App, friendlyID string) *RawPost {
var content, font, title string
var isRTL sql.NullBool
var lang sql.NullString
var ownerID sql.NullInt64
var created, updated time.Time
err := app.db.QueryRow("SELECT title, content, text_appearance, language, rtl, created, updated, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&title, &content, &font, &lang, &isRTL, &created, &updated, &ownerID)
switch {
case err == sql.ErrNoRows:
return &RawPost{Content: "", Found: false, Gone: false}
case err != nil:
log.Error("Unable to fetch getRawPost: %s", err)
return &RawPost{Content: "", Found: true, Gone: false}
}
return &RawPost{
Title: title,
Content: content,
Font: font,
Created: created,
Updated: updated,
IsRTL: isRTL,
Language: lang,
OwnerID: ownerID.Int64,
Found: true,
Gone: content == "" && title == "",
}
}
// TODO; return a Post!
func getRawCollectionPost(app *App, slug, collAlias string) *RawPost {
var id, title, content, font string
var isRTL sql.NullBool
var lang sql.NullString
var created, updated time.Time
var ownerID null.Int
var views int64
var err error
if app.cfg.App.SingleUser {
err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, updated, owner_id FROM posts WHERE slug = ? AND collection_id = 1", slug).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &updated, &ownerID)
} else {
err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, updated, owner_id FROM posts WHERE slug = ? AND collection_id = (SELECT id FROM collections WHERE alias = ?)", slug, collAlias).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &updated, &ownerID)
}
switch {
case err == sql.ErrNoRows:
return &RawPost{Content: "", Found: false, Gone: false}
case err != nil:
log.Error("Unable to fetch getRawCollectionPost: %s", err)
return &RawPost{Content: "", Found: true, Gone: false}
}
return &RawPost{
Id: id,
Slug: slug,
Title: title,
Content: content,
Font: font,
Created: created,
Updated: updated,
IsRTL: isRTL,
Language: lang,
OwnerID: ownerID.Int64,
Found: true,
Gone: content == "" && title == "",
Views: views,
}
}
func isRaw(r *http.Request) bool {
vars := mux.Vars(r)
slug := vars["slug"]
// NOTE: until this is done better, be sure to keep this in parity with
// isRaw in viewCollectionPost() and handleViewPost()
isJSON := strings.HasSuffix(slug, ".json")
isXML := strings.HasSuffix(slug, ".xml")
isMarkdown := strings.HasSuffix(slug, ".md")
return strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown
}
func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
slug := vars["slug"]
// NOTE: until this is done better, be sure to keep this in parity with
// isRaw() and handleViewPost()
isJSON := strings.HasSuffix(slug, ".json")
isXML := strings.HasSuffix(slug, ".xml")
isMarkdown := strings.HasSuffix(slug, ".md")
isRaw := strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown
cr := &collectionReq{}
err := processCollectionRequest(cr, vars, w, r)
if err != nil {
return err
}
// Check for hellbanned users
u, err := checkUserForCollection(app, cr, r, true)
if err != nil {
return err
}
// Normalize the URL, redirecting user to consistent post URL
if slug != strings.ToLower(slug) {
loc := fmt.Sprintf("/%s", strings.ToLower(slug))
if !app.cfg.App.SingleUser {
loc = "/" + cr.alias + loc
}
return impart.HTTPError{http.StatusMovedPermanently, loc}
}
// Display collection if this is a collection
var c *Collection
if app.cfg.App.SingleUser {
c, err = app.db.GetCollectionByID(1)
} else {
c, err = app.db.GetCollection(cr.alias)
}
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
if err.Status == http.StatusNotFound {
// Redirect if necessary
newAlias := app.db.GetCollectionRedirect(cr.alias)
if newAlias != "" {
return impart.HTTPError{http.StatusFound, "/" + newAlias + "/" + slug}
}
}
}
return err
}
c.hostName = app.cfg.App.Host
silenced, err := app.db.IsUserSilenced(c.OwnerID)
if err != nil {
log.Error("view collection post: %v", err)
}
// Check collection permissions
if c.IsPrivate() && (u == nil || u.ID != c.OwnerID) {
return ErrPostNotFound
}
if c.IsProtected() && (u == nil || u.ID != c.OwnerID) {
if silenced {
return ErrPostNotFound
} else if !isAuthorizedForCollection(app, c.Alias, r) {
return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/?g=" + slug}
}
}
cr.isCollOwner = u != nil && c.OwnerID == u.ID
if isRaw {
slug = strings.Split(slug, ".")[0]
}
// Fetch extra data about the Collection
// TODO: refactor out this logic, shared in collection.go:fetchCollection()
coll := NewCollectionObj(c)
owner, err := app.db.GetUserByID(coll.OwnerID)
if err != nil {
// Log the error and just continue
log.Error("Error getting user for collection: %v", err)
} else {
coll.Owner = owner
}
postFound := true
p, err := app.db.GetPost(slug, coll.ID)
if err != nil {
if err == ErrCollectionPageNotFound {
postFound = false
if slug == "feed" {
// User tried to access blog feed without a trailing slash, and
// there's no post with a slug "feed"
return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "feed/"}
} else if slug == "archive" {
// User tried to access blog Archive without a trailing slash, and
// there's no post with a slug "archive"
return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "archive/"}
}
po := &Post{
Slug: null.NewString(slug, true),
Font: "norm",
Language: zero.NewString("en", true),
RTL: zero.NewBool(false, true),
Content: `This page is missing.
Are you sure it was ever here?` + shortCodeNoSig,
}
pp := po.processPost()
p = &pp
} else {
return err
}
}
// Check if the authenticated user is the post owner
p.IsOwner = u != nil && u.ID == p.OwnerID.Int64
p.Collection = coll
p.IsTopLevel = app.cfg.App.SingleUser
// Only allow a post owner or admin to view a post for silenced collections
if silenced && !p.IsOwner && (u == nil || !u.IsAdmin()) {
return ErrPostNotFound
}
// Check if post has been unpublished
if p.Content == "" && p.Title.String == "" {
return impart.HTTPError{http.StatusGone, "Post was unpublished."}
}
p.augmentContent()
// Serve collection post
if isRaw {
contentType := "text/plain"
if isJSON {
contentType = "application/json"
} else if isXML {
contentType = "application/xml"
} else if isMarkdown {
contentType = "text/markdown"
}
w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType))
if !postFound {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "Post not found.")
// TODO: return error instead, so status is correctly reflected in logs
return nil
}
if isMarkdown && p.Title.String != "" {
fmt.Fprintf(w, "# %s\n\n", p.Title.String)
}
fmt.Fprint(w, p.Content)
} else if IsActivityPubRequest(r) {
if !postFound {
return ErrCollectionPageNotFound
}
p.extractData()
ap := p.ActivityObject(app)
ap.Context = []interface{}{activitystreams.Namespace}
setCacheControl(w, apCacheTime)
return impart.RenderActivityJSON(w, ap, http.StatusOK)
} else {
p.extractData()
p.Content = strings.Replace(p.Content, "", "", 1)
if app.cfg.Email.Enabled() && c.EmailSubsEnabled() {
// TODO: indicate plan is inactive or subs disabled when OWNER is viewing their own post.
if u != nil && u.IsEmailSubscriber(app, c.ID) {
p.Content = strings.Replace(p.Content, shortCodeEmailSub, `You're subscribed to email updates. Unsubscribe .
`, -1)
} else {
p.Content = alterShortCodeEmailSubForm(p.Content, c.Alias, p.Slug.String, false)
}
}
p.Content = strings.Replace(p.Content, "<!--emailsub-->", "", 1)
// TODO: move this to function
p.formatContent(app.cfg, cr.isCollOwner, true)
tp := CollectionPostPage{
PublicPost: p,
StaticPage: pageForReq(app, r),
IsOwner: cr.isCollOwner,
IsCustomDomain: cr.isCustomDomain,
IsFound: postFound,
Silenced: silenced,
CollAlias: c.Alias,
}
tp.IsAdmin = u != nil && u.IsAdmin()
tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin)
tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll, p.IsOwner)
tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p)
tp.Monetization = coll.Monetization
tp.Verification = coll.Verification
if tp.Verification != "" {
// Fetch info for fediverse:creator tag
ru, err := getRemoteUserFromURL(app, coll.Verification)
if err != nil {
if debugging {
log.Info("showing rel=me tag, but no local handle for %s", coll.Verification)
}
} else {
// Though we don't store handles with leading @, strip it here just in case
tp.FediverseAuthor = "@" + strings.TrimLeft(ru.Handle, "@")
}
}
if !postFound {
w.WriteHeader(http.StatusNotFound)
}
postTmpl := "collection-post"
if app.cfg.App.Chorus {
postTmpl = "chorus-collection-post"
}
if err := templates[postTmpl].ExecuteTemplate(w, "post", tp); err != nil {
log.Error("Error in %s template: %v", postTmpl, err)
}
}
go func() {
if p.OwnerID.Valid {
// Post is owned by someone. Don't update stats if owner is viewing the post.
if u != nil && p.OwnerID.Int64 == u.ID {
return
}
}
// Update stats for non-raw post views
if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) {
_, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE slug = ? AND collection_id = ?", slug, coll.ID)
if err != nil {
log.Error("Unable to update posts count: %v", err)
}
}
}()
return nil
}
// TODO: move this to utils after making it more generic
func PostsContains(sl *[]PublicPost, s *PublicPost) bool {
for _, e := range *sl {
if e.ID == s.ID {
return true
}
}
return false
}
func (p *Post) extractData() {
p.Tags = tags.Extract(p.Content)
p.extractImages()
}
func (p *Post) IsSans() bool {
return p.Font == "sans"
}
func (p *Post) IsMonospace() bool {
return p.Font == "mono"
}
func (rp *RawPost) UserFacingCreated() string {
return rp.Created.Format(postMetaDateFormat)
}
func (rp *RawPost) Created8601() string {
return rp.Created.Format("2006-01-02T15:04:05Z")
}
func (rp *RawPost) Updated8601() string {
if rp.Updated.IsZero() {
return ""
}
return rp.Updated.Format("2006-01-02T15:04:05Z")
}
var imageURLRegex = regexp.MustCompile(`(?i)[^ ]+\.(gif|png|jpg|jpeg|avif|avifs|webp|jxl|image)$`)
func (p *Post) extractImages() {
p.Images = extractImages(p.Content)
}
func extractImages(content string) []string {
matches := extract.ExtractUrls(content)
urls := map[string]bool{}
for i := range matches {
uRaw := matches[i].Text
// Parse the extracted text so we can examine the path
u, err := url.Parse(uRaw)
if err != nil {
continue
}
// Ensure the path looks like it leads to an image file
if !imageURLRegex.MatchString(u.Path) {
continue
}
urls[uRaw] = true
}
resURLs := make([]string, 0)
for k := range urls {
resURLs = append(resURLs, k)
}
return resURLs
}
diff --git a/routes.go b/routes.go
index f8979cd..dd25798 100644
--- a/routes.go
+++ b/routes.go
@@ -1,265 +1,264 @@
/*
* Copyright © 2018-2021 Musing Studio LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package writefreely
import (
"net/http"
"net/url"
"path/filepath"
"strings"
"github.com/gorilla/csrf"
"github.com/gorilla/mux"
"github.com/writeas/go-webfinger"
"github.com/writeas/web-core/log"
"github.com/writefreely/go-nodeinfo"
)
// InitStaticRoutes adds routes for serving static files.
// TODO: this should just be a func, not method
func (app *App) InitStaticRoutes(r *mux.Router) {
// Handle static files
fs := http.FileServer(http.Dir(filepath.Join(app.cfg.Server.StaticParentDir, staticDir)))
fs = cacheControl(fs)
app.shttp = http.NewServeMux()
app.shttp.Handle("/", fs)
r.PathPrefix("/").Handler(fs)
}
// InitRoutes adds dynamic routes for the given mux.Router.
func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
// Create handler
handler := NewWFHandler(apper)
// Set up routes
hostSubroute := apper.App().cfg.App.Host[strings.Index(apper.App().cfg.App.Host, "://")+3:]
if apper.App().cfg.App.SingleUser {
hostSubroute = "{domain}"
} else {
if strings.HasPrefix(hostSubroute, "localhost") {
hostSubroute = "localhost"
}
}
if apper.App().cfg.App.SingleUser {
log.Info("Adding %s routes (single user)...", hostSubroute)
} else {
log.Info("Adding %s routes (multi-user)...", hostSubroute)
}
// Primary app routes
write := r.PathPrefix("/").Subrouter()
// Federation endpoint configurations
wf := webfinger.Default(wfResolver{apper.App().db, apper.App().cfg})
wf.NoTLSHandler = nil
// Federation endpoints
// host-meta
write.HandleFunc("/.well-known/host-meta", handler.Web(handleViewHostMeta, UserLevelReader))
// webfinger
write.HandleFunc(webfinger.WebFingerPath, handler.LogHandlerFunc(http.HandlerFunc(wf.Webfinger)))
// nodeinfo
niCfg := nodeInfoConfig(apper.App().db, apper.App().cfg)
ni := nodeinfo.NewService(*niCfg, nodeInfoResolver{apper.App().cfg, apper.App().db})
write.HandleFunc(nodeinfo.NodeInfoPath, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfoDiscover)))
write.HandleFunc(niCfg.InfoURL, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfo)))
// handle mentions
write.HandleFunc("/@/{handle}", handler.Web(handleViewMention, UserLevelReader))
configureSlackOauth(handler, write, apper.App())
configureWriteAsOauth(handler, write, apper.App())
configureGitlabOauth(handler, write, apper.App())
configureGenericOauth(handler, write, apper.App())
configureGiteaOauth(handler, write, apper.App())
// Set up dynamic page handlers
// Handle auth
auth := write.PathPrefix("/api/auth/").Subrouter()
if apper.App().cfg.App.OpenRegistration {
auth.HandleFunc("/signup", handler.All(apiSignup)).Methods("POST")
}
auth.HandleFunc("/login", handler.All(login)).Methods("POST")
auth.HandleFunc("/read", handler.WebErrors(handleWebCollectionUnlock, UserLevelNone)).Methods("POST")
auth.HandleFunc("/me", handler.All(handleAPILogout)).Methods("DELETE")
// Handle logged in user sections
me := write.PathPrefix("/me").Subrouter()
me.HandleFunc("/", handler.Redirect("/me", UserLevelUser))
me.HandleFunc("/c", handler.Redirect("/me/c/", UserLevelUser)).Methods("GET")
me.HandleFunc("/c/", handler.User(viewCollections)).Methods("GET")
me.HandleFunc("/c/{collection}", handler.User(viewEditCollection)).Methods("GET")
me.HandleFunc("/c/{collection}/stats", handler.User(viewStats)).Methods("GET")
me.HandleFunc("/c/{collection}/subscribers", handler.User(handleViewSubscribers)).Methods("GET")
me.Path("/delete").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.User(handleUserDelete))).Methods("POST")
me.HandleFunc("/posts", handler.Redirect("/me/posts/", UserLevelUser)).Methods("GET")
me.HandleFunc("/posts/", handler.User(viewArticles)).Methods("GET")
me.HandleFunc("/posts/export.csv", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
me.HandleFunc("/posts/export.zip", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
me.HandleFunc("/posts/export.json", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
me.HandleFunc("/export", handler.User(viewExportOptions)).Methods("GET")
me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).Methods("GET")
me.Path("/import").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.User(viewImport))).Methods("GET")
me.Path("/settings").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.User(viewSettings))).Methods("GET")
me.Path("/invites").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.User(handleViewUserInvites))).Methods("GET")
me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET")
write.HandleFunc("/api/me", handler.All(viewMeAPI)).Methods("GET")
apiMe := write.PathPrefix("/api/me/").Subrouter()
apiMe.HandleFunc("/", handler.All(viewMeAPI)).Methods("GET")
apiMe.HandleFunc("/posts", handler.UserWebAPI(viewMyPostsAPI)).Methods("GET")
apiMe.HandleFunc("/collections", handler.UserAPI(viewMyCollectionsAPI)).Methods("GET")
apiMe.HandleFunc("/password", handler.All(updatePassphrase)).Methods("POST")
apiMe.Path("/self").Handler(csrfProtectForm(apper.App().keys.CSRFKey, handler.All(updateSettings))).Methods("POST")
apiMe.Path("/invites").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.User(handleCreateUserInvite))).Methods("POST")
apiMe.Path("/import").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.User(handleImport))).Methods("POST")
apiMe.Path("/oauth/remove").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.User(removeOauth))).Methods("POST")
// Sign up validation
write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST")
write.HandleFunc("/api/markdown", handler.All(handleRenderMarkdown)).Methods("POST")
instanceURL, _ := url.Parse(apper.App().Config().App.Host)
host := instanceURL.Host
// Handle collections
write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST")
apiColls := write.PathPrefix("/api/collections/").Subrouter()
apiColls.HandleFunc("/monetization-pointer", handler.PlainTextAPI(handleSPSPEndpoint)).Methods("GET")
apiColls.HandleFunc("/"+host, handler.AllReader(fetchCollection)).Methods("GET")
apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.AllReader(fetchCollection)).Methods("GET")
apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.All(existingCollection)).Methods("POST", "DELETE")
apiColls.HandleFunc("/{alias}/posts", handler.AllReader(fetchCollectionPosts)).Methods("GET")
apiColls.HandleFunc("/{alias}/posts", handler.All(newPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/posts/{post}", handler.AllReader(fetchPost)).Methods("GET")
apiColls.HandleFunc("/{alias}/posts/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/posts/{post}/splitcontent", handler.AllReader(handleGetSplitContent)).Methods("GET", "POST")
apiColls.HandleFunc("/{alias}/posts/{post}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET")
apiColls.HandleFunc("/{alias}/collect", handler.All(addPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/unpin", handler.All(pinPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/email/subscribers/export.csv", handler.Download(handleExportEmailSubscriptions, UserLevelUser)).Methods("GET")
apiColls.HandleFunc("/{alias}/email/subscribe", handler.All(handleCreateEmailSubscription)).Methods("POST")
apiColls.HandleFunc("/{alias}/email/subscribe", handler.All(handleDeleteEmailSubscription)).Methods("DELETE")
apiColls.HandleFunc("/{collection}/email/unsubscribe", handler.All(handleDeleteEmailSubscription)).Methods("GET")
apiColls.HandleFunc("/{alias}/inbox", handler.All(handleFetchCollectionInbox)).Methods("POST")
apiColls.HandleFunc("/{alias}/outbox", handler.AllReader(handleFetchCollectionOutbox)).Methods("GET")
apiColls.HandleFunc("/{alias}/following", handler.AllReader(handleFetchCollectionFollowing)).Methods("GET")
apiColls.HandleFunc("/{alias}/followers", handler.AllReader(handleFetchCollectionFollowers)).Methods("GET")
// Handle posts
write.HandleFunc("/api/posts", handler.All(newPost)).Methods("POST")
posts := write.PathPrefix("/api/posts/").Subrouter()
posts.HandleFunc("/claim", handler.All(addPost)).Methods("POST")
posts.HandleFunc("/disperse", handler.All(dispersePost)).Methods("POST")
posts.HandleFunc("/{post:[a-zA-Z0-9]+}", handler.AllReader(fetchPost)).Methods("GET")
posts.HandleFunc("/{post:[a-zA-Z0-9]+}", handler.All(existingPost)).Methods("POST", "PUT")
posts.HandleFunc("/{post:[a-zA-Z0-9]+}", handler.All(deletePost)).Methods("DELETE")
posts.HandleFunc("/{post:[a-zA-Z0-9]+}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET")
write.HandleFunc("/auth/signup", handler.Web(handleWebSignup, UserLevelNoneRequired)).Methods("POST")
write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST")
write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET")
write.HandleFunc("/admin/monitor", handler.Admin(handleViewAdminMonitor)).Methods("GET")
write.HandleFunc("/admin/settings", handler.Admin(handleViewAdminSettings)).Methods("GET")
write.HandleFunc("/admin/users", handler.Admin(handleViewAdminUsers)).Methods("GET")
write.HandleFunc("/admin/user/{username}", handler.Admin(handleViewAdminUser)).Methods("GET")
write.HandleFunc("/admin/user/{username}/delete", handler.Admin(handleAdminDeleteUser)).Methods("POST")
write.HandleFunc("/admin/user/{username}/status", handler.Admin(handleAdminToggleUserStatus)).Methods("POST")
write.HandleFunc("/admin/user/{username}/passphrase", handler.Admin(handleAdminResetUserPass)).Methods("POST")
write.HandleFunc("/admin/pages", handler.Admin(handleViewAdminPages)).Methods("GET")
write.HandleFunc("/admin/page/{slug}", handler.Admin(handleViewAdminPage)).Methods("GET")
write.HandleFunc("/admin/update/config", handler.AdminApper(handleAdminUpdateConfig)).Methods("POST")
write.HandleFunc("/admin/update/{page}", handler.Admin(handleAdminUpdateSite)).Methods("POST")
write.HandleFunc("/admin/updates", handler.Admin(handleViewAdminUpdates)).Methods("GET")
// Handle special pages first
write.Path("/reset").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.Web(viewResetPassword, UserLevelNoneRequired)))
write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired))
write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired))
write.HandleFunc("/invite/{code:[a-zA-Z0-9]+}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET")
// TODO: show a reader-specific 404 page if the function is disabled
write.HandleFunc("/read", handler.Web(viewLocalTimeline, UserLevelReader))
RouteRead(handler, UserLevelReader, write.PathPrefix("/read").Subrouter())
draftEditPrefix := ""
if apper.App().cfg.App.SingleUser {
draftEditPrefix = "/d"
write.HandleFunc("/me/new", handler.Web(handleViewPad, UserLevelUser)).Methods("GET")
} else {
write.HandleFunc("/new", handler.Web(handleViewPad, UserLevelUser)).Methods("GET")
}
- // All the existing stuff
- write.HandleFunc(draftEditPrefix+"/{action}/edit", handler.Web(handleViewPad, UserLevelUser)).Methods("GET")
- write.HandleFunc(draftEditPrefix+"/{action}/meta", handler.Web(handleViewMeta, UserLevelUser)).Methods("GET")
+ write.HandleFunc("/d/{action}/edit", handler.Web(handleViewPad, UserLevelUser)).Methods("GET")
+ write.HandleFunc("/d/{action}/edit/meta", handler.Web(handleViewMeta, UserLevelUser)).Methods("GET")
// Collections
if apper.App().cfg.App.SingleUser {
RouteCollections(handler, write.PathPrefix("/").Subrouter())
} else {
write.HandleFunc("/{prefix:[@~$!\\-+]}{collection}", handler.Web(handleViewCollection, UserLevelReader))
write.HandleFunc("/{collection}/", handler.Web(handleViewCollection, UserLevelReader))
RouteCollections(handler, write.PathPrefix("/{prefix:[@~$!\\-+]?}{collection}").Subrouter())
// Posts
}
write.HandleFunc(draftEditPrefix+"/{post}", handler.Web(handleViewPost, UserLevelOptional))
write.HandleFunc("/", handler.Web(handleViewHome, UserLevelOptional))
return r
}
// csrfProtectForm wraps a handler with CSRF protection but exempts JSON
// requests, which authenticate via Authorization header rather than
// session cookies and are therefore not CSRF-exploitable.
func csrfProtectForm(key []byte, next http.Handler) http.Handler {
protected := csrf.Protect(key, csrf.Path("/"))(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if IsJSON(r) {
next.ServeHTTP(w, r)
return
}
protected.ServeHTTP(w, r)
})
}
func RouteCollections(handler *Handler, r *mux.Router) {
r.HandleFunc("/logout", handler.Web(handleLogOutCollection, UserLevelOptional))
r.HandleFunc("/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader))
r.HandleFunc("/archive/", handler.Web(handleViewCollection, UserLevelReader))
r.HandleFunc("/{archive:archive}/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader))
r.HandleFunc("/lang:{lang:[a-z]{2}}", handler.Web(handleViewCollectionLang, UserLevelOptional))
r.HandleFunc("/lang:{lang:[a-z]{2}}/page/{page:[0-9]+}", handler.Web(handleViewCollectionLang, UserLevelOptional))
r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelReader))
r.HandleFunc("/tag:{tag}/page/{page:[0-9]+}", handler.Web(handleViewCollectionTag, UserLevelReader))
r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader))
r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap))
r.HandleFunc("/feed/", handler.AllReader(ViewFeed))
r.HandleFunc("/email/confirm/{subscriber}", handler.All(handleConfirmEmailSubscription)).Methods("GET")
r.HandleFunc("/email/unsubscribe/{subscriber}", handler.All(handleDeleteEmailSubscription)).Methods("GET")
r.HandleFunc("/{slug}", handler.CollectionPostOrStatic)
r.HandleFunc("/{slug}/edit", handler.Web(handleViewPad, UserLevelUser))
r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser))
r.HandleFunc("/{slug}/", handler.Web(handleCollectionPostRedirect, UserLevelReader)).Methods("GET")
}
func RouteRead(handler *Handler, readPerm UserLevelFunc, r *mux.Router) {
r.HandleFunc("/api/posts", handler.Web(viewLocalTimelineAPI, readPerm))
r.HandleFunc("/p/{page}", handler.Web(viewLocalTimeline, readPerm))
r.HandleFunc("/feed", handler.Web(viewLocalTimelineFeed, readPerm))
r.HandleFunc("/feed/", handler.Web(viewLocalTimelineFeed, readPerm))
r.HandleFunc("/t/{tag}", handler.Web(viewLocalTimeline, readPerm))
r.HandleFunc("/a/{post}", handler.Web(handlePostIDRedirect, readPerm))
r.HandleFunc("/{author}", handler.Web(viewLocalTimeline, readPerm))
r.HandleFunc("/", handler.Web(viewLocalTimeline, readPerm))
}
diff --git a/static/js/posts.js b/static/js/posts.js
index d44f19c..675e226 100644
--- a/static/js/posts.js
+++ b/static/js/posts.js
@@ -1,333 +1,333 @@
/**
* Functionality for managing local Write.as posts.
*
* Dependencies:
* h.js
*/
function toggleTheme() {
var btns;
try {
btns = Array.prototype.slice.call(document.getElementById('belt').querySelectorAll('.tool img'));
} catch (e) {}
if (document.body.className == 'light') {
document.body.className = 'dark';
try {
for (var i=0; iReading...';
var createMorePostsEl = function() {
var $more = document.createElement('div');
var nextPage = page+1;
$more.id = 'more-posts';
$more.innerHTML = 'More...
';
return $more;
};
var localPosts = function() {
var $delPost, lastDelPost, lastInfoHTML;
var $info = He.get('unsynced-posts-info');
var findPostIdx = function(id) {
for (var i=0; i -1) {
lastDelPost = posts.splice(i, 1)[0];
$delPost = H.getEl('post-'+id);
$delPost.setClass('del-undo');
var $unsyncPosts = document.getElementById('unsynced-posts');
var visible = $unsyncPosts.children.length;
for (var i=0; i < $unsyncPosts.children.length; i++) { // NOTE: *.children support in IE9+
if ($unsyncPosts.children[i].className.indexOf('del-undo') !== -1) {
visible--;
}
}
if (visible == 0) {
H.getEl('unsynced-posts-header').hide();
// TODO: fix undo functionality and don't do the following:
H.getEl('unsynced-posts-info').hide();
}
H.set('posts', JSON.stringify(posts));
// TODO: fix undo functionality and re-add
//lastInfoHTML = $info.innerHTML;
//$info.innerHTML = 'Unsynced entry deleted. Undo .';
}
};
var UndoDelete = function() {
// TODO: fix this header reappearing
H.getEl('unsynced-posts-header').show();
$delPost.removeClass('del-undo');
$info.innerHTML = lastInfoHTML;
};
return {
dismissError: DismissError,
deletePost: DeletePostLocal,
undoDelete: UndoDelete,
};
}();
var movePostHTML = function(postID) {
let $tmpl = document.getElementById('move-tmpl');
if ($tmpl === null) {
return "";
}
return $tmpl.innerHTML.replace(/POST_ID/g, postID);
}
var createPostEl = function(post, owned, singleUser) {
var $post = document.createElement('div');
let p = H.createPost(post.id, "", post.body)
var title = (post.title || p.title || post.id);
title = title.replace(/' + title + ' ';
var posted = "";
if (post.created) {
posted = getFormattedDate(new Date(post.created))
}
var hasDraft = H.exists('draft' + post.id);
- $post.innerHTML += '';
+ $post.innerHTML += '';
if (post.error) {
$post.innerHTML += 'Sync error: ' + post.error + ' dismiss remove post
';
}
if (post.summary) {
// TODO: switch to using p.summary, after ensuring it matches summary generated on the backend.
$post.innerHTML += '' + post.summary.replace(/';
} else if (post.body) {
var preview;
if (post.body.length > 140) {
preview = post.body.substr(0, 140) + '...';
} else {
preview = post.body;
}
$post.innerHTML += '
' + preview.replace(/';
}
return $post;
};
var loadPage = function(p, loadAll) {
if (loadAll) {
$posts.el.innerHTML = '';
}
var startPost = posts.length - 1 - (loadAll ? 0 : ((p-1)*postsPerPage));
var endPost = posts.length - 1 - (p*postsPerPage);
for (var i=startPost; i>=0 && i>endPost; i--) {
$posts.el.appendChild(createPostEl(posts[i]));
}
if (loadAll) {
if (p < pages) {
$posts.el.appendChild(createMorePostsEl());
}
} else {
var $moreEl = document.getElementById('more-posts');
$moreEl.parentNode.removeChild($moreEl);
}
try {
postsLoaded(posts.length);
} catch (e) {}
};
var getPageNum = function(url) {
var hash;
if (url) {
hash = url.substr(url.indexOf('#')+1);
} else {
hash = window.location.hash.substr(1);
}
var page = hash || 1;
page = parseInt(page);
if (isNaN(page)) {
page = 1;
}
return page;
};
var postsPerPage = 10;
var pages = 0;
var page = getPageNum();
window.addEventListener('hashchange', function(e) {
var newPage = getPageNum();
var didPageIncrement = newPage == getPageNum(e.oldURL) + 1;
loadPage(newPage, !didPageIncrement);
});
var deletePost = function(postID, token, callback) {
deleting = true;
var $delBtn = document.getElementById('post-' + postID).getElementsByClassName('delete action')[0];
$delBtn.innerHTML = '...';
var http = new XMLHttpRequest();
var url = "/api/posts/" + postID + (typeof token !== 'undefined' ? "?token=" + encodeURIComponent(token) : '');
http.open("DELETE", url, true);
http.onreadystatechange = function() {
if (http.readyState == 4) {
deleting = false;
if (http.status == 204 || http.status == 404) {
for (var i=0; iNo posts created yet.
' + cta + '
';
};
if (posts.length == 0) {
displayNoPosts();
} else {
initialListPop();
}
diff --git a/templates/bare.tmpl b/templates/bare.tmpl
index c2fef0f..693f64b 100644
--- a/templates/bare.tmpl
+++ b/templates/bare.tmpl
@@ -1,266 +1,266 @@
{{define "pad"}}
{{if .Editing}}Editing {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}}{{else}}New Post{{end}} — {{.SiteName}}
{{if .CustomCSS}} {{end}}
{{end}}
diff --git a/templates/classic.tmpl b/templates/classic.tmpl
index 0c411b4..c4e8dc4 100644
--- a/templates/classic.tmpl
+++ b/templates/classic.tmpl
@@ -1,408 +1,408 @@
{{define "pad"}}
{{if .Editing}}Editing {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}}{{else}}New Post{{end}} — {{.SiteName}}
{{if .CustomCSS}} {{end}}
{{end}}
diff --git a/templates/edit-meta.tmpl b/templates/edit-meta.tmpl
index 6c964b6..fd82f6a 100644
--- a/templates/edit-meta.tmpl
+++ b/templates/edit-meta.tmpl
@@ -1,379 +1,379 @@
{{define "edit-meta"}}
Edit metadata: {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}} — {{.SiteName}}
{{if .CustomCSS}} {{end}}
{{end}}
diff --git a/templates/pad.tmpl b/templates/pad.tmpl
index 079359d..9d8d1bb 100644
--- a/templates/pad.tmpl
+++ b/templates/pad.tmpl
@@ -1,463 +1,463 @@
{{define "pad"}}
{{if .Editing}}Editing {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}}{{else}}New Post{{end}} — {{.SiteName}}
{{if .CustomCSS}} {{end}}
{{end}}
diff --git a/templates/post.tmpl b/templates/post.tmpl
index ca1c1f9..188249d 100644
--- a/templates/post.tmpl
+++ b/templates/post.tmpl
@@ -1,102 +1,102 @@
{{define "post"}}
{{if .Title}}{{.Title}}{{else}}{{.GenTitle}}{{end}} {{localhtml "title dash" .Language}} {{.SiteName}}
{{if .IsCode}}
{{end}}
{{if .CustomCSS}} {{end}}
{{if gt .Views 1}}
{{end}}
{{if gt (len .Images) 0}} {{else}} {{end}}
{{range .Images}} {{else}} {{end}}
{{if .Author}} {{end}}
{{template "highlighting" .}}
{{largeNumFmt .Views}} {{pluralize "view" "views" .Views}}
{{if .IsCode}}View raw {{end}}
{{ if .Username }}
{{if .IsOwner}}
- Edit
+ Edit
{{end}}
Drafts
{{ end }}
{{if .Silenced}}
{{template "user-silenced"}}
{{end}}
{{if .Title}}{{.Title}} {{end}}{{ if .IsPlainText }}{{.Content}}
{{ else }}{{.HTMLContent}}
{{ end }}
{{if .IsCode}}
{{else}}
{{if .IsPlainText}}{{end}}
{{end}}
{{end}}
diff --git a/templates/user/articles.tmpl b/templates/user/articles.tmpl
index 3e50863..3b7d818 100644
--- a/templates/user/articles.tmpl
+++ b/templates/user/articles.tmpl
@@ -1,228 +1,228 @@
{{define "articles"}}
{{template "header" .}}
{{if .Flashes}}
{{range .Flashes}}{{.}} {{end}}
{{end}}
{{if .Silenced}}
{{template "user-silenced"}}
{{end}}
{{ if .AnonymousPosts }}
These are your draft posts. You can share them individually (without a blog) or move them to your blog when you're ready.
{{ range $el := .AnonymousPosts }}
{{.DisplayDate}}
- edit
+ edit
delete
{{ if $.Collections }}
{{if gt (len $.Collections) 1}}
{{range $.Collections}}{{.DisplayTitle}} {{end}}
move to...
{{else}}
{{range $.Collections}}
move to {{.DisplayTitle}}
{{end}}
{{end}}
{{ end }}
{{if .Summary}}
{{.SummaryHTML}}
{{end}}
{{end}}
{{if eq (len .AnonymousPosts) 10}}
Load more...
{{end}}
{{ else }}
Your anonymous and draft posts will show up here once you've published some. You'll be able to share them individually (without a blog) or move them to a blog when you're ready.
{{if not .SingleUser}}
Alternatively, see your blogs and their posts on your Blogs page.
{{end}}
Start writing
{{ end }}
{{ if .Collections }}
{{if gt (len .Collections) 1}}
{{range .Collections}}{{.DisplayTitle}} {{end}}
move to...
{{else}}
{{range .Collections}}
move to {{.DisplayTitle}}
{{end}}
{{end}}
{{ end }}
{{template "footer" .}}
{{end}}